18.1 概述
内置运行时,在进程和线程的基础上做更高层次的抽象是现代语言最流行的做法。虽然算不上激进,但Go也设计了全新的架构模型,将一切都基于并发体系之上,以适应多核时代。它刻意模糊线程或协程概念,通过三种基本对象相互协作,来实现在用户空间管理和调度并发任务。
基本关系示意图:
+--------------------sysmon---------------//------+
| |
| |
+---+ +---+-------+ +--------+ +---+---+
go func() -⇒ |G| -⇒ |P|local| ⇐balance⇒ |global| ⇐//--- |P|M| +---+ +---+-------+ +--------+ +---+---+ | | | | +---+ | | +—⇒ |M| ⇐-findrunnable---+---steal⇐//—+ +---+ | 1. 语句go func() 创建G | 2. 放入P本地队列(或平衡到全局队列) +---execute⇐---schedule 3. 唤醒或新建M执行任务 | | 4. 进入调度循环schedule | | 5. 竭力获取待执行G任务并执行 +⇒G.fn⇒goexit—+ 6. 清理现场,重新进入调度循环
首先是Processor(简称P),其作用类似CPU核,用来控制可同时并发执行的任务数量。每个工作线程都必须绑定一个有效P才被允许执行任务,否则只能休眠,直到有空闲P时被唤醒。P还为线程提供执行资源,比如对象分配内存、本地任务队列等。线程独享所绑定的P资源,可在无锁状态下执行高效操作。
基本上,进程内的一切都在以goroutine(简称G)方式运行,包括运行时相关服务,以及main.main入口函数。需要指出,G并非执行体,它仅仅保存并发任务状态,为任务执行提供所需栈内存空间。G任务创建后被放置在P本地队列或全局队列,等待工作线程调度执行。
实际执行体是系统线程(简称M),它和P绑定,以调度循环方式不停执行G并发任务。M通过修改寄存器,将执行栈指向G自带的栈内存,并在此空间内分配堆栈帧,执行任务函数。当需要中途切换时,只要将相关寄存器值保存回G空间即可维持状态,任何M都可据此恢复执行。线程仅负责执行,不再持有状态,这是并发任务跨线程调度,实现多路复用的根本所在。
尽管P/M构成执行组合体,但两者数量并非一一对应。通常情况下,P的数量相对恒定,默认与CPU核数量相同,但也可能更多或更少,而M则是由调度器按需创建的。举例来说,当M因陷入系统调用而长时间阻塞时,P就会被监控线程抢回,去新建(或唤醒)一个M执行其他任务,这样M的数量就会增长。
因为G初始栈仅有2 KB,且创建操作只是在用户空间简单地分配对象,远比进入内核态分配线程要简单得多。调度器让多个M进入调度循环,不停获取并执行任务,所以我们才能创建成千上万个并发任务。